Chapter C: When to Reach for a Library
The Library Decision Framework
The React ecosystem contains thousands of libraries. Some solve real problems elegantly. Most add complexity without proportional value. The difference between a professional React developer and a novice often comes down to knowing when to install a package versus when to write 20 lines of code.
This appendix provides a systematic framework for making library decisions, organized by problem domain. For each category, we'll establish:
- The threshold: When the problem becomes complex enough to justify a dependency
- The failure mode: What happens when you try to solve it yourself
- The recommended solution: Which library to use and why
- The integration pattern: How to use it correctly in React/Next.js
- The escape hatch: When to avoid the library despite the complexity
The Core Principle: Solve Problems, Not Patterns
Before reaching for any library, ask three questions:
- Do I have this problem right now? (Not "might I have it someday")
- Have I tried solving it with 50 lines of code first?
- Will this library still be maintained in 2 years?
If you answer "no" to any of these, write the code yourself.
Reference Implementation: The Feature-Rich Dashboard
Throughout this appendix, we'll build a production dashboard that encounters real complexity:
- Date/time handling: User activity timestamps, relative dates, timezone conversions
- Form validation: Multi-step user onboarding with complex rules
- File uploads: Profile pictures and document attachments
- Data visualization: Activity charts and usage metrics
- Animations: Smooth transitions and loading states
We'll start with naive implementations, observe their failures, and introduce libraries only when the complexity justifies the dependency.
// src/components/Dashboard.tsx - Initial structure
// This will evolve as we encounter complexity
import { useState } from 'react';
interface User {
id: string;
name: string;
email: string;
createdAt: string; // ISO 8601 timestamp
lastActive: string;
timezone: string;
}
interface Activity {
id: string;
userId: string;
action: string;
timestamp: string;
metadata: Record<string, unknown>;
}
export function Dashboard() {
const [user, setUser] = useState<User | null>(null);
const [activities, setActivities] = useState<Activity[]>([]);
const [isLoading, setIsLoading] = useState(true);
// We'll add functionality as we encounter problems
return (
<div className="dashboard">
<header>
<h1>Dashboard</h1>
</header>
<main>
{/* User profile section */}
{/* Activity timeline */}
{/* Analytics charts */}
{/* Settings form */}
</main>
</div>
);
}
Date and Time: When date-fns Becomes Essential
The Naive Approach: JavaScript's Date Object
Let's add a user activity timeline that shows when actions occurred:
// src/components/ActivityTimeline.tsx - Iteration 1: Native Date
interface Activity {
id: string;
action: string;
timestamp: string; // "2025-01-15T14:30:00Z"
}
export function ActivityTimeline({ activities }: { activities: Activity[] }) {
const formatTimestamp = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins} minutes ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours} hours ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} days ago`;
};
return (
<div className="timeline">
{activities.map(activity => (
<div key={activity.id} className="activity-item">
<p>{activity.action}</p>
<time>{formatTimestamp(activity.timestamp)}</time>
</div>
))}
</div>
);
}
The Failure: Edge Cases Multiply
Scenario: User in Tokyo (UTC+9) views activity from New York (UTC-5).
Browser Console:
Activity timestamp: "2025-01-15T14:30:00Z"
User local time: "2025-01-15T23:30:00" (Tokyo)
Displayed: "9 hours ago"
Expected: "just now" (activity happened at 2:30 PM UTC, which is 11:30 PM Tokyo time)
The problem: Our naive implementation doesn't handle: - Timezone conversions - Daylight saving time transitions - Month boundaries (30 vs 31 days) - Leap years - Locale-specific formatting ("2 days ago" vs "vor 2 Tagen")
Let's try to fix it:
// Iteration 2: Attempting to handle timezones
const formatTimestamp = (timestamp: string, userTimezone: string) => {
const date = new Date(timestamp);
// Convert to user's timezone... how?
// JavaScript's Date object doesn't have built-in timezone conversion
// We'd need to:
// 1. Parse the ISO string
// 2. Get UTC offset for user's timezone
// 3. Account for DST
// 4. Handle historical timezone changes
// 5. Format according to locale
// This is getting complicated...
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: userTimezone,
hour: 'numeric',
minute: 'numeric',
});
// But Intl.DateTimeFormat doesn't give us relative times
// And calculating "2 days ago" across timezone boundaries is complex
return formatter.format(date); // Falls back to absolute time
};
Diagnostic Analysis: The Complexity Threshold
What we need: 1. Relative time formatting ("2 hours ago") 2. Timezone-aware calculations 3. Locale support 4. DST handling 5. Date arithmetic (add/subtract days, months)
Lines of code to implement correctly: ~500+ (with tests)
Maintenance burden: High (timezone rules change, locales evolve)
This crosses the threshold: Date/time manipulation is a solved problem with well-maintained libraries.
The Solution: date-fns
Why date-fns over alternatives: - Modular: Import only what you need (tree-shakeable) - Immutable: Functions don't mutate dates - TypeScript-first: Excellent type definitions - Actively maintained: 50k+ GitHub stars, regular updates - Lightweight: ~2KB per function (vs. 67KB for Moment.js)
Installation:
npm install date-fns
Iteration 3: Using date-fns
// src/components/ActivityTimeline.tsx - Iteration 3: date-fns
import { formatDistanceToNow, parseISO, format } from 'date-fns';
import { enUS, de, ja } from 'date-fns/locale';
interface Activity {
id: string;
action: string;
timestamp: string;
}
interface Props {
activities: Activity[];
locale?: 'en' | 'de' | 'ja';
}
const localeMap = {
en: enUS,
de: de,
ja: ja,
};
export function ActivityTimeline({ activities, locale = 'en' }: Props) {
const formatTimestamp = (timestamp: string) => {
const date = parseISO(timestamp);
// Relative time with locale support
return formatDistanceToNow(date, {
addSuffix: true,
locale: localeMap[locale],
});
};
const formatAbsolute = (timestamp: string) => {
const date = parseISO(timestamp);
// Absolute time for hover tooltip
return format(date, 'PPpp', {
locale: localeMap[locale],
});
};
return (
<div className="timeline">
{activities.map(activity => (
<div key={activity.id} className="activity-item">
<p>{activity.action}</p>
<time
dateTime={activity.timestamp}
title={formatAbsolute(activity.timestamp)}
>
{formatTimestamp(activity.timestamp)}
</time>
</div>
))}
</div>
);
}
Browser Output (English locale):
"2 hours ago"
"5 minutes ago"
"3 days ago"
Browser Output (German locale):
"vor 2 Stunden"
"vor 5 Minuten"
"vor 3 Tagen"
Hover tooltip (absolute time):
"January 15, 2025 at 2:30 PM"
Advanced Use Case: Date Range Filtering
Let's add a filter to show activities from the last 7 days:
// src/components/ActivityFilters.tsx
import { subDays, isAfter, parseISO } from 'date-fns';
interface Props {
activities: Activity[];
onFilterChange: (filtered: Activity[]) => void;
}
export function ActivityFilters({ activities, onFilterChange }: Props) {
const filterByDateRange = (days: number) => {
const cutoffDate = subDays(new Date(), days);
const filtered = activities.filter(activity => {
const activityDate = parseISO(activity.timestamp);
return isAfter(activityDate, cutoffDate);
});
onFilterChange(filtered);
};
return (
<div className="filters">
<button onClick={() => filterByDateRange(7)}>
Last 7 days
</button>
<button onClick={() => filterByDateRange(30)}>
Last 30 days
</button>
<button onClick={() => onFilterChange(activities)}>
All time
</button>
</div>
);
}
When to Apply date-fns
Use date-fns when: - Displaying relative times ("2 hours ago") - Formatting dates for different locales - Performing date arithmetic (add/subtract days, months) - Parsing various date formats - Comparing dates across timezones
Skip date-fns when:
- Only displaying ISO timestamps as-is
- Simple new Date() for current timestamp
- Basic date comparison without timezone concerns
- Bundle size is critical and you only need one function (write it yourself)
Bundle impact: ~2-5KB per function (tree-shakeable)
Maintenance: Low (import and use, no configuration needed)
Form Validation: When Zod Becomes Non-Negotiable
The Naive Approach: Manual Validation
Let's add a user settings form to our dashboard:
// src/components/SettingsForm.tsx - Iteration 1: Manual validation
import { useState } from 'react';
interface FormData {
email: string;
displayName: string;
bio: string;
website: string;
}
export function SettingsForm() {
const [formData, setFormData] = useState<FormData>({
email: '',
displayName: '',
bio: '',
website: '',
});
const [errors, setErrors] = useState<Partial<Record<keyof FormData, string>>>({});
const validateForm = (): boolean => {
const newErrors: Partial<Record<keyof FormData, string>> = {};
// Email validation
if (!formData.email) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
// Display name validation
if (!formData.displayName) {
newErrors.displayName = 'Display name is required';
} else if (formData.displayName.length < 3) {
newErrors.displayName = 'Display name must be at least 3 characters';
}
// Bio validation
if (formData.bio.length > 500) {
newErrors.bio = 'Bio must be less than 500 characters';
}
// Website validation
if (formData.website && !/^https?:\/\/.+/.test(formData.website)) {
newErrors.website = 'Website must be a valid URL';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
// Submit to API...
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="displayName">Display Name</label>
<input
id="displayName"
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
/>
{errors.displayName && <span className="error">{errors.displayName}</span>}
</div>
<div>
<label htmlFor="bio">Bio</label>
<textarea
id="bio"
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
/>
{errors.bio && <span className="error">{errors.bio}</span>}
</div>
<div>
<label htmlFor="website">Website</label>
<input
id="website"
type="url"
value={formData.website}
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
/>
{errors.website && <span className="error">{errors.website}</span>}
</div>
<button type="submit">Save Settings</button>
</form>
);
}
The Failure: Client-Only Validation is Insufficient
Scenario: User submits form with malicious data bypassing client validation.
Browser DevTools - Network Tab:
POST /api/user/settings
Request Payload:
{
"email": "attacker@evil.com",
"displayName": "<script>alert('xss')</script>",
"bio": "A".repeat(10000), // 10,000 characters
"website": "javascript:alert('xss')"
}
Response: 400 Bad Request
{
"error": "Validation failed",
"details": "Bio exceeds maximum length"
}
The problem: 1. Client validation can be bypassed (disable JavaScript, modify request) 2. Server needs the same validation logic (code duplication) 3. Validation rules are scattered across the codebase 4. No type safety between form data and API expectations
Let's try to add server-side validation:
// src/app/api/user/settings/route.ts - Attempting server validation
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const body = await request.json();
// Duplicate validation logic from client
const errors: Record<string, string> = {};
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
errors.email = 'Invalid email';
}
if (!body.displayName || body.displayName.length < 3) {
errors.displayName = 'Display name must be at least 3 characters';
}
if (body.bio && body.bio.length > 500) {
errors.bio = 'Bio must be less than 500 characters';
}
if (body.website && !/^https?:\/\/.+/.test(body.website)) {
errors.website = 'Invalid URL';
}
if (Object.keys(errors).length > 0) {
return NextResponse.json({ errors }, { status: 400 });
}
// Save to database...
return NextResponse.json({ success: true });
}
Diagnostic Analysis: The Duplication Problem
Issues with manual validation:
1. Code duplication: Same rules in client and server
2. Drift risk: Client and server validation diverge over time
3. No type safety: body is any, no autocomplete or type checking
4. Maintenance burden: Adding a field requires updating 4+ places
5. Error handling: Manual error aggregation is error-prone
Lines of code for production-ready validation: ~200+ per form
This crosses the threshold: Form validation is complex enough to justify a schema library.
The Solution: Zod
Why Zod over alternatives: - TypeScript-first: Infers types from schemas automatically - Runtime validation: Catches invalid data at runtime - Composable: Build complex schemas from simple ones - Error messages: Detailed, customizable validation errors - Framework agnostic: Works in client, server, and API routes
Installation:
npm install zod
Iteration 2: Shared Validation Schema
// src/lib/schemas/user.ts - Single source of truth
import { z } from 'zod';
export const userSettingsSchema = z.object({
email: z
.string()
.min(1, 'Email is required')
.email('Invalid email format'),
displayName: z
.string()
.min(3, 'Display name must be at least 3 characters')
.max(50, 'Display name must be less than 50 characters'),
bio: z
.string()
.max(500, 'Bio must be less than 500 characters')
.optional(),
website: z
.string()
.url('Website must be a valid URL')
.optional()
.or(z.literal('')), // Allow empty string
});
// TypeScript type inferred automatically
export type UserSettings = z.infer<typeof userSettingsSchema>;
Iteration 3: Client-Side Form with Zod
// src/components/SettingsForm.tsx - Iteration 3: Zod validation
import { useState } from 'react';
import { userSettingsSchema, type UserSettings } from '@/lib/schemas/user';
import { z } from 'zod';
export function SettingsForm() {
const [formData, setFormData] = useState<UserSettings>({
email: '',
displayName: '',
bio: '',
website: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validate with Zod
const result = userSettingsSchema.safeParse(formData);
if (!result.success) {
// Convert Zod errors to form errors
const formErrors: Record<string, string> = {};
result.error.errors.forEach((err) => {
if (err.path[0]) {
formErrors[err.path[0].toString()] = err.message;
}
});
setErrors(formErrors);
return;
}
// Type-safe data (result.data is UserSettings)
try {
const response = await fetch('/api/user/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(result.data),
});
if (!response.ok) {
const error = await response.json();
setErrors(error.errors || {});
}
} catch (error) {
console.error('Failed to save settings:', error);
}
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
/>
{errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="displayName">Display Name</label>
<input
id="displayName"
type="text"
value={formData.displayName}
onChange={(e) => setFormData({ ...formData, displayName: e.target.value })}
/>
{errors.displayName && <span className="error">{errors.displayName}</span>}
</div>
<div>
<label htmlFor="bio">Bio</label>
<textarea
id="bio"
value={formData.bio}
onChange={(e) => setFormData({ ...formData, bio: e.target.value })}
/>
{errors.bio && <span className="error">{errors.bio}</span>}
</div>
<div>
<label htmlFor="website">Website</label>
<input
id="website"
type="url"
value={formData.website}
onChange={(e) => setFormData({ ...formData, website: e.target.value })}
/>
{errors.website && <span className="error">{errors.website}</span>}
</div>
<button type="submit">Save Settings</button>
</form>
);
}
Iteration 4: Server-Side Validation with Same Schema
// src/app/api/user/settings/route.ts - Iteration 4: Zod on server
import { NextRequest, NextResponse } from 'next/server';
import { userSettingsSchema } from '@/lib/schemas/user';
export async function POST(request: NextRequest) {
const body = await request.json();
// Same validation as client
const result = userSettingsSchema.safeParse(body);
if (!result.success) {
const errors: Record<string, string> = {};
result.error.errors.forEach((err) => {
if (err.path[0]) {
errors[err.path[0].toString()] = err.message;
}
});
return NextResponse.json({ errors }, { status: 400 });
}
// result.data is type-safe UserSettings
const validatedData = result.data;
// Save to database with confidence
// await db.user.update({
// where: { id: session.user.id },
// data: validatedData,
// });
return NextResponse.json({ success: true });
}
Browser Console (invalid submission):
Validation errors:
{
"email": "Invalid email format",
"displayName": "Display name must be at least 3 characters"
}
Network Tab (malicious payload):
POST /api/user/settings
Request: { "bio": "A".repeat(10000) }
Response: 400 Bad Request
{
"errors": {
"bio": "Bio must be less than 500 characters"
}
}
Advanced Pattern: Nested Schemas and Transformations
// src/lib/schemas/user.ts - Advanced Zod patterns
import { z } from 'zod';
// Nested object validation
export const addressSchema = z.object({
street: z.string().min(1),
city: z.string().min(1),
state: z.string().length(2), // US state code
zipCode: z.string().regex(/^\d{5}(-\d{4})?$/),
});
// Discriminated union for user roles
export const userRoleSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('admin'),
permissions: z.array(z.string()),
}),
z.object({
type: z.literal('user'),
tier: z.enum(['free', 'pro', 'enterprise']),
}),
]);
// Data transformation
export const userProfileSchema = z.object({
email: z.string().email().toLowerCase(), // Transform to lowercase
age: z.string().transform((val) => parseInt(val, 10)), // String to number
tags: z.string().transform((val) => val.split(',').map(s => s.trim())), // CSV to array
createdAt: z.string().datetime().transform((val) => new Date(val)), // ISO string to Date
});
// Conditional validation
export const passwordChangeSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().min(8),
confirmPassword: z.string().min(8),
}).refine((data) => data.newPassword === data.confirmPassword, {
message: "Passwords don't match",
path: ['confirmPassword'],
});
// Async validation (e.g., check if email exists)
export const signupSchema = z.object({
email: z.string().email(),
username: z.string().min(3),
}).refine(async (data) => {
// Check if username is available
const response = await fetch(`/api/check-username?username=${data.username}`);
const { available } = await response.json();
return available;
}, {
message: 'Username is already taken',
path: ['username'],
});
When to Apply Zod
Use Zod when: - Validating user input (forms, API requests) - Parsing external data (API responses, file uploads) - Ensuring type safety at runtime boundaries - Sharing validation logic between client and server - Building type-safe APIs
Skip Zod when:
- Simple presence checks (if (!value))
- Internal function parameters (use TypeScript types)
- Performance-critical hot paths (validation has overhead)
- Single-use validation that won't be reused
Bundle impact: ~12KB minified + gzipped
Maintenance: Low (define schema once, use everywhere)
Integration with React Hook Form:
npm install react-hook-form @hookform/resolvers
// src/components/SettingsForm.tsx - React Hook Form + Zod
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { userSettingsSchema, type UserSettings } from '@/lib/schemas/user';
export function SettingsForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<UserSettings>({
resolver: zodResolver(userSettingsSchema),
});
const onSubmit = async (data: UserSettings) => {
// data is already validated and type-safe
const response = await fetch('/api/user/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
// Handle server errors
}
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" {...register('email')} />
{errors.email && <span className="error">{errors.email.message}</span>}
</div>
<div>
<label htmlFor="displayName">Display Name</label>
<input id="displayName" type="text" {...register('displayName')} />
{errors.displayName && <span className="error">{errors.displayName.message}</span>}
</div>
<div>
<label htmlFor="bio">Bio</label>
<textarea id="bio" {...register('bio')} />
{errors.bio && <span className="error">{errors.bio.message}</span>}
</div>
<div>
<label htmlFor="website">Website</label>
<input id="website" type="url" {...register('website')} />
{errors.website && <span className="error">{errors.website.message}</span>}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Settings'}
</button>
</form>
);
}
File Uploads: When react-dropzone Saves Time
The Naive Approach: Basic File Input
Let's add profile picture upload to our dashboard:
// src/components/ProfilePictureUpload.tsx - Iteration 1: Basic input
import { useState } from 'react';
export function ProfilePictureUpload() {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (!selectedFile) return;
// Basic validation
if (!selectedFile.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (selectedFile.size > 5 * 1024 * 1024) { // 5MB
alert('File size must be less than 5MB');
return;
}
setFile(selectedFile);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(selectedFile);
};
const handleUpload = async () => {
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const response = await fetch('/api/upload/profile-picture', {
method: 'POST',
body: formData,
});
if (response.ok) {
alert('Upload successful!');
}
};
return (
<div>
<input
type="file"
accept="image/*"
onChange={handleFileChange}
/>
{preview && (
<div>
<img src={preview} alt="Preview" style={{ width: 200, height: 200 }} />
<button onClick={handleUpload}>Upload</button>
</div>
)}
</div>
);
}
The Failure: Poor User Experience
Scenario: User tries to drag and drop an image.
Browser Behavior: - Drag and drop doesn't work (not implemented) - No visual feedback during drag - No progress indicator during upload - No way to cancel upload - No handling of multiple files - Ugly default file input styling
User Experience Issues: 1. Can't drag and drop files 2. No upload progress 3. No way to remove selected file 4. No validation feedback before upload 5. No handling of upload errors 6. Mobile experience is poor
Let's try to add drag and drop:
// Iteration 2: Attempting drag and drop
import { useState, useRef } from 'react';
export function ProfilePictureUpload() {
const [file, setFile] = useState<File | null>(null);
const [isDragging, setIsDragging] = useState(false);
const dropZoneRef = useRef<HTMLDivElement>(null);
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
// Only set to false if leaving the drop zone entirely
// This is tricky because dragLeave fires when entering child elements
if (e.currentTarget === dropZoneRef.current) {
setIsDragging(false);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
// Validate files
const imageFiles = droppedFiles.filter(file =>
file.type.startsWith('image/')
);
if (imageFiles.length === 0) {
alert('Please drop an image file');
return;
}
// Handle first image only
setFile(imageFiles[0]);
};
return (
<div
ref={dropZoneRef}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
style={{
border: isDragging ? '2px dashed blue' : '2px dashed gray',
padding: '20px',
textAlign: 'center',
}}
>
{/* This is getting complex and we haven't even handled:
- Multiple files
- File validation
- Upload progress
- Error handling
- Accessibility
*/}
Drop image here or click to select
</div>
);
}
Diagnostic Analysis: The Complexity Threshold
What we need for production-ready file upload: 1. Drag and drop with visual feedback 2. File validation (type, size, dimensions) 3. Multiple file support 4. Upload progress tracking 5. Error handling and retry logic 6. Preview generation 7. Accessibility (keyboard navigation, screen readers) 8. Mobile-friendly interface
Lines of code to implement correctly: ~400+ (with tests)
Edge cases to handle: - Drag leave events on child elements - Browser compatibility (Safari, Firefox) - Touch events on mobile - Memory leaks from FileReader - Concurrent uploads
This crosses the threshold: File upload UX is complex enough to justify a library.
The Solution: react-dropzone
Why react-dropzone: - Drag and drop: Built-in with proper event handling - Validation: File type, size, and custom validators - Accessibility: ARIA attributes and keyboard support - Hooks-based: Clean React integration - Lightweight: ~10KB minified + gzipped - Well-maintained: 10k+ GitHub stars, active development
Installation:
npm install react-dropzone
Iteration 3: Using react-dropzone
// src/components/ProfilePictureUpload.tsx - Iteration 3: react-dropzone
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
export function ProfilePictureUpload() {
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [uploadProgress, setUploadProgress] = useState(0);
const [isUploading, setIsUploading] = useState(false);
const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: any[]) => {
// Handle rejected files
if (rejectedFiles.length > 0) {
const rejection = rejectedFiles[0];
const error = rejection.errors[0];
if (error.code === 'file-too-large') {
alert('File is too large. Maximum size is 5MB.');
} else if (error.code === 'file-invalid-type') {
alert('Invalid file type. Please upload an image.');
}
return;
}
// Handle accepted file
const selectedFile = acceptedFiles[0];
setFile(selectedFile);
// Create preview
const reader = new FileReader();
reader.onloadend = () => {
setPreview(reader.result as string);
};
reader.readAsDataURL(selectedFile);
}, []);
const {
getRootProps,
getInputProps,
isDragActive,
isDragReject,
} = useDropzone({
onDrop,
accept: {
'image/*': ['.png', '.jpg', '.jpeg', '.gif', '.webp'],
},
maxSize: 5 * 1024 * 1024, // 5MB
maxFiles: 1,
multiple: false,
});
const handleUpload = async () => {
if (!file) return;
setIsUploading(true);
setUploadProgress(0);
const formData = new FormData();
formData.append('file', file);
try {
// Using XMLHttpRequest for progress tracking
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = (e.loaded / e.total) * 100;
setUploadProgress(progress);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
alert('Upload successful!');
setFile(null);
setPreview(null);
} else {
alert('Upload failed');
}
setIsUploading(false);
});
xhr.addEventListener('error', () => {
alert('Upload failed');
setIsUploading(false);
});
xhr.open('POST', '/api/upload/profile-picture');
xhr.send(formData);
} catch (error) {
console.error('Upload error:', error);
setIsUploading(false);
}
};
return (
<div>
<div
{...getRootProps()}
style={{
border: isDragActive
? '2px dashed #0070f3'
: isDragReject
? '2px dashed #ff0000'
: '2px dashed #ccc',
borderRadius: '8px',
padding: '40px',
textAlign: 'center',
cursor: 'pointer',
backgroundColor: isDragActive ? '#f0f8ff' : 'transparent',
transition: 'all 0.2s ease',
}}
>
<input {...getInputProps()} />
{isDragActive ? (
<p>Drop the image here...</p>
) : isDragReject ? (
<p style={{ color: 'red' }}>Invalid file type or size</p>
) : (
<div>
<p>Drag and drop an image here, or click to select</p>
<p style={{ fontSize: '0.875rem', color: '#666' }}>
PNG, JPG, GIF up to 5MB
</p>
</div>
)}
</div>
{preview && (
<div style={{ marginTop: '20px' }}>
<img
src={preview}
alt="Preview"
style={{
width: '200px',
height: '200px',
objectFit: 'cover',
borderRadius: '8px',
}}
/>
<div style={{ marginTop: '10px' }}>
<button
onClick={handleUpload}
disabled={isUploading}
style={{ marginRight: '10px' }}
>
{isUploading ? 'Uploading...' : 'Upload'}
</button>
<button
onClick={() => {
setFile(null);
setPreview(null);
}}
disabled={isUploading}
>
Remove
</button>
</div>
{isUploading && (
<div style={{ marginTop: '10px' }}>
<progress value={uploadProgress} max="100" />
<span>{Math.round(uploadProgress)}%</span>
</div>
)}
</div>
)}
</div>
);
}
Browser Behavior (drag and drop):
1. User drags image over drop zone
→ Border changes to blue, background lightens
2. User drags non-image file
→ Border changes to red, error message shows
3. User drops valid image
→ Preview appears immediately
→ Upload button becomes active
4. User clicks upload
→ Progress bar shows 0% → 100%
→ Success message on completion
Advanced Pattern: Multiple Files with Previews
// src/components/DocumentUpload.tsx - Multiple files
import { useCallback, useState } from 'react';
import { useDropzone } from 'react-dropzone';
interface FileWithPreview extends File {
preview: string;
}
export function DocumentUpload() {
const [files, setFiles] = useState<FileWithPreview[]>([]);
const onDrop = useCallback((acceptedFiles: File[]) => {
const filesWithPreviews = acceptedFiles.map(file =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
);
setFiles(prev => [...prev, ...filesWithPreviews]);
}, []);
const { getRootProps, getInputProps } = useDropzone({
onDrop,
accept: {
'image/*': [],
'application/pdf': ['.pdf'],
'application/msword': ['.doc', '.docx'],
},
maxSize: 10 * 1024 * 1024, // 10MB
});
const removeFile = (fileToRemove: FileWithPreview) => {
// Revoke object URL to prevent memory leak
URL.revokeObjectURL(fileToRemove.preview);
setFiles(files.filter(file => file !== fileToRemove));
};
// Cleanup on unmount
useEffect(() => {
return () => {
files.forEach(file => URL.revokeObjectURL(file.preview));
};
}, [files]);
return (
<div>
<div {...getRootProps()} style={{ /* styles */ }}>
<input {...getInputProps()} />
<p>Drop files here or click to select</p>
</div>
{files.length > 0 && (
<div style={{ marginTop: '20px' }}>
<h3>Selected Files ({files.length})</h3>
<ul>
{files.map((file, index) => (
<li key={index} style={{ marginBottom: '10px' }}>
{file.type.startsWith('image/') && (
<img
src={file.preview}
alt={file.name}
style={{ width: '50px', height: '50px', objectFit: 'cover' }}
/>
)}
<span>{file.name}</span>
<span>({(file.size / 1024).toFixed(2)} KB)</span>
<button onClick={() => removeFile(file)}>Remove</button>
</li>
))}
</ul>
</div>
)}
</div>
);
}
When to Apply react-dropzone
Use react-dropzone when: - Building file upload interfaces - Need drag and drop functionality - Require file validation (type, size) - Want accessible file inputs - Need multiple file support - Building document management systems
Skip react-dropzone when:
- Simple single file input without drag and drop
- Only need basic <input type="file" />
- Building mobile-only app (native file picker is better)
- Bundle size is critical and you only need basic upload
Bundle impact: ~10KB minified + gzipped
Maintenance: Low (hook-based API, minimal configuration)
Data Visualization: When Recharts Simplifies Charts
The Naive Approach: Canvas or SVG from Scratch
Let's add an activity chart to our dashboard:
// src/components/ActivityChart.tsx - Iteration 1: Manual SVG
interface DataPoint {
date: string;
count: number;
}
interface Props {
data: DataPoint[];
}
export function ActivityChart({ data }: Props) {
const width = 600;
const height = 300;
const padding = 40;
// Calculate scales
const maxCount = Math.max(...data.map(d => d.count));
const xScale = (width - 2 * padding) / (data.length - 1);
const yScale = (height - 2 * padding) / maxCount;
// Generate path for line chart
const pathData = data
.map((point, index) => {
const x = padding + index * xScale;
const y = height - padding - point.count * yScale;
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
})
.join(' ');
return (
<svg width={width} height={height}>
{/* Y-axis */}
<line
x1={padding}
y1={padding}
x2={padding}
y2={height - padding}
stroke="black"
/>
{/* X-axis */}
<line
x1={padding}
y1={height - padding}
x2={width - padding}
y2={height - padding}
stroke="black"
/>
{/* Data line */}
<path
d={pathData}
fill="none"
stroke="blue"
strokeWidth="2"
/>
{/* Data points */}
{data.map((point, index) => {
const x = padding + index * xScale;
const y = height - padding - point.count * yScale;
return (
<circle
key={index}
cx={x}
cy={y}
r="4"
fill="blue"
/>
);
})}
</svg>
);
}
The Failure: Missing Essential Features
Scenario: User hovers over a data point expecting to see details.
Browser Behavior: - No tooltip appears - No interactivity - No axis labels - No legend - No responsive sizing - No animation
What's missing for production: 1. Tooltips on hover 2. Axis labels and ticks 3. Legend 4. Responsive sizing 5. Animations 6. Multiple data series 7. Different chart types (bar, area, pie) 8. Accessibility (ARIA labels, keyboard navigation)
Let's try to add tooltips:
// Iteration 2: Attempting to add tooltips
import { useState } from 'react';
export function ActivityChart({ data }: Props) {
const [tooltip, setTooltip] = useState<{
x: number;
y: number;
data: DataPoint;
} | null>(null);
// ... previous code ...
const handleMouseMove = (e: React.MouseEvent<SVGSVGElement>) => {
const rect = e.currentTarget.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
const mouseY = e.clientY - rect.top;
// Find closest data point
// This calculation is complex and error-prone
const index = Math.round((mouseX - padding) / xScale);
if (index >= 0 && index < data.length) {
const point = data[index];
const x = padding + index * xScale;
const y = height - padding - point.count * yScale;
// Check if mouse is near the point
const distance = Math.sqrt(
Math.pow(mouseX - x, 2) + Math.pow(mouseY - y, 2)
);
if (distance < 10) {
setTooltip({ x: mouseX, y: mouseY, data: point });
} else {
setTooltip(null);
}
}
};
return (
<div style={{ position: 'relative' }}>
<svg
width={width}
height={height}
onMouseMove={handleMouseMove}
onMouseLeave={() => setTooltip(null)}
>
{/* ... previous SVG code ... */}
</svg>
{tooltip && (
<div
style={{
position: 'absolute',
left: tooltip.x + 10,
top: tooltip.y - 30,
background: 'white',
border: '1px solid black',
padding: '5px',
borderRadius: '4px',
pointerEvents: 'none',
}}
>
<div>{tooltip.data.date}</div>
<div>Count: {tooltip.data.count}</div>
</div>
)}
</div>
);
}
Diagnostic Analysis: The Complexity Threshold
What we need for production-ready charts: 1. Multiple chart types (line, bar, area, pie, scatter) 2. Interactive tooltips with formatting 3. Axis labels, ticks, and grid lines 4. Legend with toggle functionality 5. Responsive sizing and mobile support 6. Animations and transitions 7. Accessibility features 8. Export functionality (PNG, SVG, CSV)
Lines of code to implement correctly: ~1000+ per chart type
Edge cases to handle: - Negative values - Missing data points - Zero values - Large datasets (performance) - Different time scales - Multiple Y-axes - Stacked charts
This crosses the threshold: Data visualization is complex enough to justify a library.
The Solution: Recharts
Why Recharts over alternatives: - React-first: Built specifically for React - Declarative: Compose charts from components - Responsive: Built-in responsive container - Customizable: Full control over styling - TypeScript support: Excellent type definitions - Lightweight: ~50KB minified + gzipped (reasonable for features)
Alternatives considered: - Chart.js: Imperative API, not React-native - D3.js: Powerful but steep learning curve, large bundle - Victory: Similar to Recharts, slightly larger bundle - Nivo: Beautiful defaults, but heavier
Installation:
npm install recharts
Iteration 3: Using Recharts
// src/components/ActivityChart.tsx - Iteration 3: Recharts
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
interface DataPoint {
date: string;
count: number;
}
interface Props {
data: DataPoint[];
}
export function ActivityChart({ data }: Props) {
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="count"
stroke="#0070f3"
strokeWidth={2}
dot={{ r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
);
}
Browser Behavior:
1. Chart renders with smooth animation
2. Hover over data point → Tooltip appears with formatted data
3. Resize window → Chart scales responsively
4. Grid lines and axis labels automatically positioned
What we got for ~20 lines of code: - Responsive sizing - Interactive tooltips - Axis labels and ticks - Grid lines - Smooth animations - Hover effects - Legend
Advanced Pattern: Multiple Data Series
// src/components/ActivityChart.tsx - Multiple series
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import { format, parseISO } from 'date-fns';
interface DataPoint {
date: string;
views: number;
clicks: number;
conversions: number;
}
interface Props {
data: DataPoint[];
}
// Custom tooltip component
function CustomTooltip({ active, payload, label }: any) {
if (!active || !payload) return null;
return (
<div
style={{
background: 'white',
border: '1px solid #ccc',
borderRadius: '4px',
padding: '10px',
}}
>
<p style={{ fontWeight: 'bold', marginBottom: '5px' }}>
{format(parseISO(label), 'MMM d, yyyy')}
</p>
{payload.map((entry: any, index: number) => (
<p key={index} style={{ color: entry.color, margin: '2px 0' }}>
{entry.name}: {entry.value.toLocaleString()}
</p>
))}
</div>
);
}
export function ActivityChart({ data }: Props) {
return (
<ResponsiveContainer width="100%" height={400}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(date) => format(parseISO(date), 'MMM d')}
/>
<YAxis />
<Tooltip content={<CustomTooltip />} />
<Legend />
<Line
type="monotone"
dataKey="views"
stroke="#0070f3"
strokeWidth={2}
name="Views"
/>
<Line
type="monotone"
dataKey="clicks"
stroke="#00c853"
strokeWidth={2}
name="Clicks"
/>
<Line
type="monotone"
dataKey="conversions"
stroke="#ff6d00"
strokeWidth={2}
name="Conversions"
/>
</LineChart>
</ResponsiveContainer>
);
}
Different Chart Types
// Bar Chart
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
export function ActivityBarChart({ data }: Props) {
return (
<ResponsiveContainer width="100%" height={300}>
<BarChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#0070f3" />
</BarChart>
</ResponsiveContainer>
);
}
// Area Chart
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
export function ActivityAreaChart({ data }: Props) {
return (
<ResponsiveContainer width="100%" height={300}>
<AreaChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis />
<Tooltip />
<Area
type="monotone"
dataKey="count"
stroke="#0070f3"
fill="#0070f3"
fillOpacity={0.3}
/>
</AreaChart>
</ResponsiveContainer>
);
}
// Pie Chart
import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts';
interface PieDataPoint {
name: string;
value: number;
}
const COLORS = ['#0070f3', '#00c853', '#ff6d00', '#9c27b0'];
export function ActivityPieChart({ data }: { data: PieDataPoint[] }) {
return (
<ResponsiveContainer width="100%" height={300}>
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
);
}
When to Apply Recharts
Use Recharts when: - Building dashboards with data visualization - Need interactive charts with tooltips - Require multiple chart types - Want responsive charts - Need customizable styling - Building analytics interfaces
Skip Recharts when: - Only need simple progress bars or gauges (use CSS) - Building real-time streaming charts (use lightweight canvas library) - Need 3D charts (use Three.js or specialized library) - Bundle size is critical and you only need one simple chart type
Bundle impact: ~50KB minified + gzipped
Maintenance: Low (declarative API, minimal configuration)
Performance considerations: - Works well up to ~1000 data points - For larger datasets, consider: - Data aggregation/sampling - Virtual scrolling - Canvas-based libraries (Chart.js, uPlot)
Animations: When Framer Motion Adds Polish
The Naive Approach: CSS Transitions
Let's add smooth transitions to our dashboard components:
// src/components/NotificationBanner.tsx - Iteration 1: CSS transitions
import { useState } from 'react';
interface Props {
message: string;
type: 'success' | 'error' | 'info';
}
export function NotificationBanner({ message, type }: Props) {
const [isVisible, setIsVisible] = useState(true);
if (!isVisible) return null;
return (
<div
style={{
padding: '16px',
borderRadius: '8px',
marginBottom: '16px',
backgroundColor: type === 'success' ? '#d4edda' : type === 'error' ? '#f8d7da' : '#d1ecf1',
transition: 'opacity 0.3s ease-out',
opacity: isVisible ? 1 : 0,
}}
>
<p>{message}</p>
<button onClick={() => setIsVisible(false)}>Dismiss</button>
</div>
);
}
The Failure: Abrupt DOM Removal
Scenario: User clicks "Dismiss" button.
Browser Behavior:
1. User clicks dismiss
2. setIsVisible(false) executes
3. Component returns null immediately
4. Banner disappears instantly (no fade-out animation)
The problem: React removes the component from the DOM before the CSS transition completes.
Let's try to fix it with a delay:
// Iteration 2: Attempting delayed removal
import { useState, useEffect } from 'react';
export function NotificationBanner({ message, type }: Props) {
const [isVisible, setIsVisible] = useState(true);
const [shouldRender, setShouldRender] = useState(true);
const handleDismiss = () => {
setIsVisible(false);
// Wait for transition to complete before removing from DOM
setTimeout(() => {
setShouldRender(false);
}, 300); // Must match CSS transition duration
};
if (!shouldRender) return null;
return (
<div
style={{
padding: '16px',
borderRadius: '8px',
marginBottom: '16px',
backgroundColor: type === 'success' ? '#d4edda' : type === 'error' ? '#f8d7da' : '#d1ecf1',
transition: 'opacity 0.3s ease-out',
opacity: isVisible ? 1 : 0,
}}
>
<p>{message}</p>
<button onClick={handleDismiss}>Dismiss</button>
</div>
);
}
This works, but has issues: 1. Manual timeout management (error-prone) 2. Timeout duration must match CSS (easy to desync) 3. No cleanup if component unmounts during animation 4. Can't easily animate entry (mount) 5. Complex animations require more state management
The Failure: Complex Animation Sequences
Scenario: Add a notification list where items slide in from the right and stack.
What we need: 1. Slide in animation on mount 2. Fade out animation on dismiss 3. Stagger animations for multiple items 4. Reorder animations when items are removed 5. Spring physics for natural motion
Lines of code with CSS + manual state: ~150+ per component
This crosses the threshold: Complex animations justify a library.
The Solution: Framer Motion
Why Framer Motion: - Declarative: Animations defined in JSX - Exit animations: Handles unmount animations automatically - Layout animations: Smooth transitions when layout changes - Gestures: Drag, hover, tap with physics - Spring physics: Natural, realistic motion - TypeScript support: Excellent type definitions
Installation:
npm install framer-motion
Iteration 3: Using Framer Motion
// src/components/NotificationBanner.tsx - Iteration 3: Framer Motion
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
interface Props {
message: string;
type: 'success' | 'error' | 'info';
}
export function NotificationBanner({ message, type }: Props) {
const [isVisible, setIsVisible] = useState(true);
const backgroundColor =
type === 'success' ? '#d4edda' :
type === 'error' ? '#f8d7da' :
'#d1ecf1';
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, x: 100 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -100 }}
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
style={{
padding: '16px',
borderRadius: '8px',
marginBottom: '16px',
backgroundColor,
}}
>
<p>{message}</p>
<button onClick={() => setIsVisible(false)}>Dismiss</button>
</motion.div>
)}
</AnimatePresence>
);
}
Browser Behavior:
1. Component mounts → Slides in from right with spring physics
2. User clicks dismiss → Slides out to left with fade
3. Component unmounts only after animation completes
What we got: - Entry animation (slide in from right) - Exit animation (slide out to left) - Spring physics (natural bounce) - Automatic cleanup - No manual timeout management
Advanced Pattern: Notification List with Stagger
// src/components/NotificationList.tsx - Staggered animations
import { motion, AnimatePresence } from 'framer-motion';
import { useState } from 'react';
interface Notification {
id: string;
message: string;
type: 'success' | 'error' | 'info';
}
export function NotificationList() {
const [notifications, setNotifications] = useState<Notification[]>([]);
const addNotification = (notification: Omit<Notification, 'id'>) => {
const newNotification = {
...notification,
id: Math.random().toString(36).substr(2, 9),
};
setNotifications(prev => [...prev, newNotification]);
// Auto-dismiss after 5 seconds
setTimeout(() => {
removeNotification(newNotification.id);
}, 5000);
};
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
};
return (
<div style={{ position: 'fixed', top: 20, right: 20, width: 300 }}>
<AnimatePresence>
{notifications.map((notification, index) => (
<motion.div
key={notification.id}
initial={{ opacity: 0, x: 100, scale: 0.8 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -100, scale: 0.8 }}
transition={{
type: 'spring',
stiffness: 300,
damping: 30,
delay: index * 0.1, // Stagger effect
}}
layout // Smooth reordering when items are removed
style={{
padding: '16px',
borderRadius: '8px',
marginBottom: '12px',
backgroundColor:
notification.type === 'success' ? '#d4edda' :
notification.type === 'error' ? '#f8d7da' :
'#d1ecf1',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
>
<p style={{ margin: 0, marginBottom: '8px' }}>
{notification.message}
</p>
<button
onClick={() => removeNotification(notification.id)}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
fontSize: '0.875rem',
}}
>
Dismiss
</button>
</motion.div>
))}
</AnimatePresence>
{/* Demo buttons */}
<div style={{ marginTop: '20px' }}>
<button
onClick={() => addNotification({
message: 'Success! Your changes have been saved.',
type: 'success',
})}
>
Add Success
</button>
<button
onClick={() => addNotification({
message: 'Error! Something went wrong.',
type: 'error',
})}
>
Add Error
</button>
</div>
</div>
);
}
Browser Behavior:
1. Click "Add Success" 3 times rapidly
→ Each notification slides in with 0.1s delay (stagger)
→ Smooth spring physics on each entry
2. First notification auto-dismisses after 5s
→ Slides out to left
→ Remaining notifications smoothly move up (layout animation)
3. Click dismiss on middle notification
→ That notification slides out
→ Others smoothly reposition
Advanced Pattern: Drag and Drop with Physics
// src/components/DraggableCard.tsx - Drag with constraints
import { motion } from 'framer-motion';
interface Props {
title: string;
content: string;
}
export function DraggableCard({ title, content }: Props) {
return (
<motion.div
drag
dragConstraints={{ left: 0, right: 300, top: 0, bottom: 300 }}
dragElastic={0.1}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
style={{
width: 200,
padding: '20px',
borderRadius: '8px',
backgroundColor: 'white',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
cursor: 'grab',
}}
>
<h3>{title}</h3>
<p>{content}</p>
</motion.div>
);
}
Advanced Pattern: Page Transitions
// src/components/PageTransition.tsx - Route transitions
import { motion, AnimatePresence } from 'framer-motion';
import { usePathname } from 'next/navigation';
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<AnimatePresence mode="wait">
<motion.div
key={pathname}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{children}
</motion.div>
</AnimatePresence>
);
}
// Usage in layout
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<PageTransition>{children}</PageTransition>
</body>
</html>
);
}
When to Apply Framer Motion
Use Framer Motion when: - Need exit animations (unmount transitions) - Building complex animation sequences - Want gesture support (drag, hover, tap) - Need layout animations (smooth reordering) - Building interactive UI with physics - Want spring-based animations
Skip Framer Motion when: - Simple CSS transitions are sufficient - Only need basic hover effects - Bundle size is critical (Framer Motion is ~30KB) - Building performance-critical animations (use CSS or GSAP)
Bundle impact: ~30KB minified + gzipped
Maintenance: Low (declarative API, minimal configuration)
Performance considerations: - Uses GPU acceleration automatically - Optimizes for 60fps - Can animate hundreds of elements smoothly - For thousands of elements, consider CSS animations
The Complete Library Decision Framework
Decision Matrix: When to Install vs. When to Write
Category: Date/Time Manipulation
| Complexity | Write Yourself | Use Library (date-fns) |
|---|---|---|
| Display current date | ✅ new Date().toLocaleDateString() |
❌ Overkill |
| Relative time ("2 hours ago") | ❌ Complex edge cases | ✅ formatDistanceToNow() |
| Timezone conversions | ❌ DST, historical changes | ✅ formatInTimeZone() |
| Date arithmetic | ❌ Month boundaries, leap years | ✅ addDays(), subMonths() |
Threshold: Need more than basic date display or comparison.
Bundle cost: ~2-5KB per function (tree-shakeable)
Category: Form Validation
| Complexity | Write Yourself | Use Library (Zod) |
|---|---|---|
| Single field presence check | ✅ if (!value) |
❌ Overkill |
| Email format validation | ⚠️ Regex is tricky | ✅ z.string().email() |
| Multi-field form with types | ❌ Code duplication | ✅ Schema + type inference |
| Server + client validation | ❌ Drift risk | ✅ Shared schema |
Threshold: More than 3 fields or need server-side validation.
Bundle cost: ~12KB (one-time cost for entire app)
Category: File Uploads
| Complexity | Write Yourself | Use Library (react-dropzone) |
|---|---|---|
| Basic file input | ✅ <input type="file" /> |
❌ Overkill |
| Drag and drop | ❌ Event handling complexity | ✅ Built-in |
| File validation | ⚠️ Manual checks | ✅ Declarative config |
| Multiple files + previews | ❌ State management hell | ✅ Hooks-based API |
Threshold: Need drag and drop or multiple file support.
Bundle cost: ~10KB
Category: Data Visualization
| Complexity | Write Yourself | Use Library (Recharts) |
|---|---|---|
| Progress bar | ✅ CSS width percentage | ❌ Overkill |
| Simple bar chart | ⚠️ ~100 lines SVG | ✅ <BarChart> |
| Interactive line chart | ❌ Tooltips, axes, legend | ✅ Declarative components |
| Multiple chart types | ❌ 1000+ lines per type | ✅ Swap components |
Threshold: Need interactivity or multiple chart types.
Bundle cost: ~50KB (reasonable for features)
Category: Animations
| Complexity | Write Yourself | Use Library (Framer Motion) |
|---|---|---|
| Hover effect | ✅ CSS :hover |
❌ Overkill |
| Fade in on mount | ✅ CSS transition | ❌ Overkill |
| Exit animation | ❌ Manual timeout | ✅ <AnimatePresence> |
| Drag and drop | ❌ Complex gesture handling | ✅ drag prop |
| Layout animations | ❌ FLIP technique | ✅ layout prop |
Threshold: Need exit animations or complex gestures.
Bundle cost: ~30KB
The Professional's Checklist
Before installing any library, ask:
1. Problem Validation
- [ ] Do I have this problem right now? (Not "might need it")
- [ ] Have I tried solving it with 50 lines of code first?
- [ ] Is this problem complex enough to justify a dependency?
2. Library Evaluation
- [ ] Is it actively maintained? (commits in last 3 months)
- [ ] Does it have good TypeScript support?
- [ ] What's the bundle size impact?
- [ ] How many dependencies does it have?
- [ ] Is the API stable? (check changelog for breaking changes)
3. Integration Assessment
- [ ] Does it work with React 18+ features?
- [ ] Is it compatible with Next.js App Router?
- [ ] Does it support Server Components (if relevant)?
- [ ] Are there known issues with my other dependencies?
4. Long-term Viability
- [ ] Will this library still be maintained in 2 years?
- [ ] Is there a clear migration path if it's abandoned?
- [ ] Can I easily replace it if needed?
- [ ] Is the API simple enough to learn quickly?
The Curated Toolkit: Libraries Worth Installing
These libraries have proven themselves in production and are worth the dependency:
Essential (Install on Most Projects)
date-fns - Date manipulation - Why: JavaScript's Date API is insufficient - When: Any app with dates beyond basic display - Bundle: ~2-5KB per function
Zod - Runtime validation - Why: Type safety at runtime boundaries - When: Any form or API with validation - Bundle: ~12KB
React Hook Form - Form state management - Why: Uncontrolled forms with validation - When: Forms with more than 3 fields - Bundle: ~9KB
Situational (Install When Needed)
react-dropzone - File uploads - Why: Drag and drop is complex - When: File upload interfaces - Bundle: ~10KB
Recharts - Data visualization - Why: Charts from scratch are 1000+ lines - When: Dashboards, analytics - Bundle: ~50KB
Framer Motion - Animations - Why: Exit animations and gestures - When: Interactive UI with complex animations - Bundle: ~30KB
TanStack Query - Server state - Why: Caching, refetching, optimistic updates - When: Data-heavy apps with frequent fetching - Bundle: ~13KB
Zustand - Global state - Why: Simpler than Context, more flexible than useState - When: State shared across many components - Bundle: ~1KB
Specialized (Install for Specific Use Cases)
react-pdf - PDF rendering - When: Displaying PDFs in browser - Bundle: ~200KB (heavy, but necessary)
react-markdown - Markdown rendering - When: Rendering user-generated markdown - Bundle: ~30KB
react-hot-toast - Toast notifications - When: Need toast notifications - Bundle: ~4KB
react-icons - Icon library - When: Need many icons - Bundle: Tree-shakeable, ~1KB per icon
Anti-Patterns: Libraries to Avoid
❌ Moment.js
Why avoid: Unmaintained, huge bundle (67KB) Use instead: date-fns (tree-shakeable, maintained)
❌ Lodash (full import)
Why avoid: Importing entire library (70KB) Use instead: Import specific functions or use native JS
❌ jQuery
Why avoid: Unnecessary with React Use instead: React's built-in DOM manipulation
❌ Redux (for most apps)
Why avoid: Boilerplate, complexity Use instead: Zustand, Context API, or TanStack Query
❌ Styled Components / Emotion
Why avoid: Runtime CSS-in-JS has performance cost Use instead: Tailwind CSS, CSS Modules, or vanilla CSS
The Bundle Budget
Set a budget for your app's JavaScript bundle:
Target bundle sizes (minified + gzipped): - Landing page: < 50KB - Dashboard: < 150KB - Complex app: < 300KB
How to measure:
# Next.js build analysis
npm run build
# Install bundle analyzer
npm install -D @next/bundle-analyzer
// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({
// your config
});
# Analyze bundle
ANALYZE=true npm run build
What to look for: 1. Largest dependencies (consider alternatives) 2. Duplicate dependencies (fix with package manager) 3. Unused code (remove or lazy load) 4. Opportunities for code splitting
The Maintenance Burden
Every library you add creates maintenance work:
Low maintenance (set and forget): - date-fns - Zod - react-dropzone
Medium maintenance (occasional updates): - Recharts - Framer Motion - TanStack Query
High maintenance (frequent breaking changes): - UI component libraries (Material-UI, Chakra) - State management (Redux, MobX) - Form libraries with complex APIs
Minimize maintenance by: 1. Choosing stable, mature libraries 2. Avoiding libraries with frequent breaking changes 3. Using libraries with good TypeScript support 4. Preferring simple APIs over complex ones
The Final Rule: Solve Problems, Not Patterns
The best library is the one you don't install.
Before reaching for a library, ask: 1. Can I solve this with 50 lines of code? → Write it yourself 2. Will this code be reused across the app? → Consider a library 3. Is this a solved problem with a mature solution? → Use the library 4. Will this library still be maintained in 2 years? → Proceed with confidence
The goal is not to minimize dependencies at all costs. The goal is to solve real problems with appropriate tools.
A well-chosen library is an investment. A poorly-chosen library is technical debt.
Choose wisely.